Separating UI, Business Logic, and Data Layers in iOS Development
Course: iOS App Development
Topic: Separating UI, Business Logic, and Data Layers
Course Link: iOS App Development Training
Demo Link: Register for a Free Course Demo
Website: JustAcademy
1. Introduction
In iOS app development, separating the user interface (UI), business logic, and data layers is an important software design practice. It helps developers create applications that are easier to understand, test, maintain, debug, and extend.
When all application code is written inside a single view controller or SwiftUI view, the project can quickly become difficult to manage. UI rendering, validation rules, API requests, database operations, and error handling may become tightly connected. A small change in one area can then affect many other parts of the application.
Layered architecture addresses this problem by assigning each responsibility to a dedicated part of the application. The UI displays information and collects user input, the business logic determines how the application should behave, and the data layer manages communication with APIs, databases, and other data sources.
2. Learning Objectives
- Understand the purpose of separating application responsibilities.
- Identify the UI, business logic, and data layers in an iOS project.
- Learn how SwiftUI and UIKit interact with business logic.
- Implement a simple layered architecture using Swift.
- Understand repositories, services, and dependency injection.
- Handle loading states, errors, and asynchronous operations.
- Improve testability, maintainability, and code reuse.
3. What Is Layered Architecture?
Layered architecture is a software design approach in which an application is divided into logical layers. Each layer has a clearly defined responsibility and communicates with other layers through well-defined interfaces.
In a typical iOS application, the three main layers are:
- Presentation or UI Layer: Displays content and receives user interactions.
- Business Logic Layer: Implements application rules, validation, calculations, and workflows.
- Data Layer: Retrieves, stores, transforms, and persists application data.
For example, in a shopping application, the UI displays products and a checkout button. The business logic calculates the order total and verifies checkout rules. The data layer retrieves product information and sends the order to a remote API.
4. Understanding the Three Layers
4.1 UI Layer (Presentation Layer)
The UI layer is responsible for everything the user sees and interacts with. In modern iOS development, this layer is commonly implemented using SwiftUI or UIKit.
Main responsibilities:
- Displaying text, images, lists, buttons, forms, and navigation elements.
- Receiving user input and gestures.
- Showing loading indicators, empty states, and error messages.
- Reflecting changes in application state.
- Forwarding user actions to the appropriate business logic component.
Common technologies:
- SwiftUI views, such as
Text, Button, List, and NavigationStack.
- UIKit components, such as
UIView, UIViewController, and UITableView.
- View models that expose presentation-ready state.
The UI layer should generally avoid implementing network requests, complex calculations, or database queries directly. Its primary goal is to present data and communicate user intent.
4.2 Business Logic Layer
The business logic layer contains the rules that determine how the application behaves. It interprets user actions, validates information, performs calculations, and coordinates application workflows.
Main responsibilities:
- Validating user input.
- Calculating prices, discounts, totals, or other derived values.
- Checking business rules and eligibility conditions.
- Coordinating operations that involve multiple data sources.
- Deciding what should happen when an operation succeeds or fails.
- Preparing results for the UI layer.
Business logic may be implemented using view models, use-case classes, domain services, or plain Swift types. The best choice depends on the application's size and complexity.
For example, a registration feature might require a valid email address, a password of sufficient length, and confirmation that the password fields match. These rules should not be scattered across multiple buttons and UI components.
4.3 Data Layer
The data layer is responsible for obtaining and managing application data. It isolates the rest of the application from the details of remote APIs, local databases, file storage, and caching mechanisms.
Main responsibilities:
- Sending HTTP requests and receiving API responses.
- Decoding JSON into Swift models.
- Reading and writing local data.
- Managing caching and persistence.
- Handling data-source-specific errors.
- Providing consistent interfaces for accessing data.
Common technologies include URLSession, Codable, SwiftData, Core Data, and repository or service classes.
The UI should not need to know whether a product came from a server, a local database, or a cache. A repository can hide those implementation details behind a clear interface.
5. How the Layers Communicate
A typical flow starts when the user performs an action. The UI passes that action to a view model or another business logic component. The business logic requests information from the data layer, which accesses the appropriate source and returns a result. The business logic then updates the state observed by the UI.
User Interaction
|
v
UI Layer (SwiftUI / UIKit)
|
v
Business Logic Layer
(ViewModel / Use Case)
|
v
Data Layer
(Repository / API Service)
|
v
Remote API / Local Database
|
v
Data returned to Business Logic
|
v
UI state updated and displayed
This diagram represents a common arrangement rather than a mandatory architecture. Some applications introduce domain models, coordinators, or additional layers when the requirements justify them.
6. Why Should These Layers Be Separated?
6.1 Better Maintainability
When responsibilities are separated, developers can modify one part of the application without unnecessarily changing unrelated code. For example, replacing a REST API with a different service can often be handled in the data layer while leaving the UI largely unchanged.
6.2 Easier Testing
Business rules can be tested independently from screens and real network services. A repository protocol can be replaced by a mock implementation during unit tests.
6.3 Code Reusability
The same business logic or repository can be reused by multiple screens. A product repository, for instance, may support a product list, a search screen, and a product details screen.
6.4 Improved Collaboration
Developers can work on the UI, application rules, and data integration with fewer conflicts when component responsibilities and interfaces are agreed upon.
6.5 Easier Debugging
Clear boundaries make it easier to identify where a problem originates. A layout issue belongs to the UI, an incorrect discount calculation belongs to business logic, and a malformed server response belongs to the data integration process.
7. Example Project: Displaying Products in an iOS App
Consider a product catalogue application. The screen must display a list of products retrieved from a remote service.
The implementation will use:
- Model: Represents product data.
- Data layer: Defines a repository and fetches products.
- Business logic layer: Uses a view model to coordinate loading and manage screen state.
- UI layer: Displays products and loading or error messages.
7.1 Define the Product Model
Create a Swift structure representing the data returned by the API.
import Foundation
struct Product: Identifiable, Decodable {
let id: Int
let title: String
let price: Double
}
The Decodable conformance allows Swift's decoding system to convert compatible JSON data into a Product value. The Identifiable conformance helps SwiftUI identify individual products in a list.
7.2 Define a Repository Interface
A repository protocol describes what the application needs from the data layer without specifying how the data is retrieved.
import Foundation
protocol ProductRepository {
func fetchProducts() async throws -> [Product]
}
The business logic depends on this abstraction rather than directly depending on a particular networking implementation. A live repository and a test repository can both conform to the same protocol.
7.3 Implement the Remote Data Source
The following repository uses URLSession to request products from a sample endpoint. Replace the example URL with the actual endpoint used by your application.
import Foundation
enum ProductRepositoryError: Error {
case invalidResponse
case unsuccessfulStatusCode(Int)
}
struct RemoteProductRepository: ProductRepository {
let url: URL
func fetchProducts() async throws -> [Product] {
let (data, response) = try await URLSession.shared.data(
from: url
)
guard let httpResponse = response as? HTTPURLResponse else {
throw ProductRepositoryError.invalidResponse
}
guard (200...299).contains(httpResponse.statusCode) else {
throw ProductRepositoryError.unsuccessfulStatusCode(
httpResponse.statusCode
)
}
return try JSONDecoder().decode(
[Product].self,
from: data
)
}
}
This component handles the HTTP request, validates the HTTP status code, and decodes the response. It throws errors so the caller can decide how to respond.
In production applications, also consider request timeouts, authentication, cancellation, response validation, logging, and API-specific error formats.
7.4 Implement the Business Logic with a View Model
The view model coordinates the loading operation and exposes state that the UI can display. It does not need to know how the repository performs its request.
import Foundation
import Combine
@MainActor
final class ProductViewModel: ObservableObject {
@Published private(set) var products: [Product] = []
@Published private(set) var isLoading = false
@Published private(set) var errorMessage: String?
private let repository: any ProductRepository
init(repository: any ProductRepository) {
self.repository = repository
}
func loadProducts() async {
isLoading = true
errorMessage = nil
defer {
isLoading = false
}
do {
products = try await repository.fetchProducts()
} catch {
errorMessage = "Unable to load products. Please try again."
}
}
}
The view model owns the screen's loading state and coordinates the repository call. The @MainActor annotation ensures its isolated state updates occur on the main actor, which is appropriate for UI-observed state.
The example uses a user-friendly error message. A larger application may map specific errors into appropriate messages, preserve diagnostics for logging, and distinguish connectivity problems from server failures.
7.5 Build the SwiftUI View
The UI observes the view model and renders the state. It does not perform networking directly.
import SwiftUI
struct ProductListView: View {
@StateObject private var viewModel: ProductViewModel
init(repository: any ProductRepository) {
_viewModel = StateObject(
wrappedValue: ProductViewModel(repository: repository)
)
}
var body: some View {
NavigationStack {
Group {
if viewModel.isLoading {
ProgressView("Loading products...")
} else if let error = viewModel.errorMessage {
VStack(spacing: 12) {
Text(error)
.multilineTextAlignment(.center)
Button("Try Again") {
Task {
await viewModel.loadProducts()
}
}
}
.padding()
} else if viewModel.products.isEmpty {
ContentUnavailableView(
"No Products",
systemImage: "shippingbox",
description: Text(
"Products will appear here when available."
)
)
} else {
List(viewModel.products) { product in
HStack {
Text(product.title)
Spacer()
Text(
product.price,
format: .currency(code: "USD")
)
}
}
}
}
.navigationTitle("Products")
.task {
await viewModel.loadProducts()
}
}
}
}
The view handles presentation and user interaction. The view model manages the loading process, and the repository handles data retrieval. The currency code is illustrative; use the currency appropriate to the application.
7.6 Configure the Dependencies
At the application's composition point, create the repository and inject it into the screen.
let repository = RemoteProductRepository(
url: URL(string: "https://example.com/api/products")!
)
ProductListView(repository: repository)
In a complete SwiftUI application, this view can be placed inside the app's root scene or navigation hierarchy. Validate configuration URLs safely in production rather than force-unwrapping untrusted input.
8. Understanding the Role of MVVM
Model-View-ViewModel (MVVM) is a presentation architecture often used to support separation of concerns in iOS applications.
| MVVM Component |
Primary Responsibility |
Example |
| Model |
Represents application data or domain concepts. |
Product |
| View |
Displays information and captures user interactions. |
ProductListView |
| ViewModel |
Exposes presentation state and coordinates screen actions. |
ProductViewModel |
MVVM helps separate the UI from presentation logic, but it does not automatically create a complete three-layer architecture. For larger applications, repositories and use cases can keep networking and complex business rules outside the view model.
9. Business Logic Versus Presentation Logic
These two responsibilities are related but not identical.
| Presentation Logic |
Business Logic |
| Controls how loading indicators and messages appear. |
Determines whether an operation is allowed. |
| Formats information for a screen. |
Calculates values according to business rules. |
| Chooses which UI state to display. |
Validates data and enforces application policies. |
| Responds to navigation and view events. |
Coordinates domain workflows and decisions. |
For a simple app, some presentation logic and application rules may reasonably live in a view model. However, important business rules that must be reused across multiple screens or tested independently should be extracted into dedicated types or use cases.
10. Using a Service and Repository Together
In larger applications, a service and a repository may have separate roles.
- API service: Performs network-specific operations, such as constructing requests, setting headers, and decoding responses.
- Repository: Provides an application-oriented interface for obtaining data and can coordinate remote and local sources.
- Use case: Encapsulates a particular application operation when that workflow deserves its own component.
- View model: Coordinates screen state and invokes the required application operations.
These are design options, not mandatory classes for every project. Avoid adding layers that do not provide a meaningful boundary or benefit.
11. Separating Data Models from UI Models
API response structures do not always match the information a screen needs. A data-transfer model can represent the server response, while a domain model represents application concepts and a presentation model represents screen-ready information.
For example, a server might return a price in cents and a timestamp in a specific format. The data layer can decode the response, the business layer can interpret the values, and the UI can display a formatted price and date.
Separating models is especially useful when APIs change independently of the UI. For small applications, one model may be sufficient if its responsibilities remain clear.
12. Error Handling Across the Layers
Errors should be handled at the layer that has enough context to respond appropriately.
| Layer |
Example Problem |
Appropriate Responsibility |
| Data |
Network timeout or invalid JSON. |
Throw or translate a meaningful technical error. |
| Business logic |
Checkout rule fails. |
Apply the rule and return a suitable domain result or error. |
| UI |
Product list cannot load. |
Show a useful message and an appropriate retry action. |
Avoid silently swallowing errors or showing raw technical details to end users. Preserve enough diagnostic information to investigate failures while keeping private data and credentials out of logs.
13. Unit Testing the Business Logic
Dependency injection makes it possible to test a view model without contacting a real API. A mock repository can provide predictable results.
import XCTest
struct MockProductRepository: ProductRepository {
let result: [Product]
func fetchProducts() async throws -> [Product] {
result
}
}
@MainActor
final class ProductViewModelTests: XCTestCase {
func testLoadProductsPublishesResults() async {
let expected = [
Product(id: 1, title: "Keyboard", price: 49.99)
]
let repository = MockProductRepository(result: expected)
let viewModel = ProductViewModel(repository: repository)
await viewModel.loadProducts()
XCTAssertEqual(viewModel.products.count, 1)
XCTAssertEqual(viewModel.products.first?.title, "Keyboard")
XCTAssertNil(viewModel.errorMessage)
XCTAssertFalse(viewModel.isLoading)
}
}
This test checks the successful loading path. Additional tests should cover empty results, repository failures, retries, validation rules, and any important business decisions. The sample mock returns fixed results; a throwing mock can be introduced to test error handling.
14. Common Mistakes to Avoid
- Putting everything in a view controller: Large controllers become difficult to test and maintain.
- Making network requests directly from UI components: This tightly couples rendering to infrastructure.
- Duplicating business rules: The same validation or calculation may behave differently across screens.
- Creating unnecessary layers: Excessive abstractions can make small projects harder to understand.
- Using global singletons for every dependency: Hidden dependencies make testing and configuration more difficult.
- Ignoring loading and empty states: Users may not understand what the application is doing.
- Mixing technical and user-facing errors: Raw networking errors are often confusing or unsafe to display.
- Ignoring concurrency: UI-observed state should be updated using appropriate actor isolation and concurrency practices.
- Testing only the UI: Business rules and data handling also need focused unit tests.
15. Best Practices
- Give every component a clear and limited responsibility.
- Prefer protocols or other clear interfaces where implementations may vary.
- Use dependency injection to make dependencies explicit and replaceable.
- Keep reusable business rules independent of SwiftUI and UIKit whenever practical.
- Use
async/await for asynchronous work where appropriate.
- Model loading, success, empty, and failure states clearly.
- Keep secrets, API credentials, and sensitive data out of source code and logs.
- Write unit tests for business rules and repository-dependent workflows.
- Use Swift's type system to make invalid states harder to represent.
- Choose the simplest architecture that meets the project's actual requirements.
16. When Should You Add More Layers?
A small utility app may need only a few views, a view model, and a simple data service. More layers become useful when the application has complex workflows, multiple data sources, substantial business rules, or several teams working on the same codebase.
| Application Size or Need |
Possible Approach |
| Small, mostly local app |
Views, a few models, and a focused data service. |
| Medium app with multiple screens |
Views, view models, repositories, and clear model types. |
| Complex business workflows |
Separate use cases or domain services with repositories. |
| Multiple data sources |
Repositories that coordinate remote, local, and cached data. |
| Large application with many modules |
Feature modules, explicit dependency boundaries, and dedicated testing strategies. |
17. Practical Exercises
- Profile screen: Separate profile rendering, profile validation, and profile persistence.
- Login feature: Build a login view, an authentication view model or use case, and an authentication repository.
- Weather application: Place API requests in a data service and display loading, success, and error states in the UI.
- Shopping cart: Move subtotal, discount, and total calculations into testable business logic.
- Offline catalogue: Extend a repository to return cached results when network access is unavailable.
- Unit tests: Replace a real repository with a mock and verify how the view model responds to success and failure.
18. Interview Questions and Answers
Q1. What does separation of concerns mean in iOS development?
Answer: It means organizing code so that each component has a distinct responsibility, such as presenting the UI, enforcing business rules, or retrieving data.
Q2. What is the UI layer responsible for?
Answer: The UI layer renders information, receives user interactions, and reflects application state. It should generally delegate complex rules and data access to other components.
Q3. Where should API requests be implemented?
Answer: API requests are generally implemented in the data layer, often in a networking service or repository, rather than directly inside a view.
Q4. What is the difference between a view model and a repository?
Answer: A view model manages presentation state and coordinates screen actions. A repository provides an interface for retrieving or persisting data and hides data-source details.
Q5. Why is dependency injection useful?
Answer: Dependency injection makes dependencies explicit and allows different implementations to be supplied. This supports testing, configuration, and replacing services without rewriting the consumer.
Q6. Does MVVM automatically separate all business logic?
Answer: No. MVVM separates view responsibilities from view-model responsibilities, but repositories and use cases may still be needed to keep data access and complex business rules independent.
Q7. How can a view model be tested without a live API?
Answer: Define a repository interface and inject a mock or fake implementation that returns controlled data or throws predictable errors.
Q8. What is the purpose of a repository?
Answer: A repository provides a consistent data-access interface and can hide whether data comes from a remote API, local persistence, or a cache.
Q9. Why should UI code avoid complex business calculations?
Answer: Duplicated or UI-bound calculations are harder to reuse and test. Centralizing important rules helps ensure consistent behavior across screens.
Q10. Is a three-layer architecture required for every iOS application?
Answer: No. Architecture should match the application's complexity. Small applications can remain simple, while larger applications benefit from stronger boundaries and additional abstractions.
19. Quick Revision Checklist
- UI layer: presents content and captures user actions.
- Business logic layer: applies rules and coordinates application operations.
- Data layer: communicates with APIs, databases, and other data sources.
- View models: expose screen state and coordinate presentation actions.
- Repositories: provide clear interfaces for data access.
- Dependency injection: allows components to receive replaceable dependencies.
- Unit tests: verify business behavior without requiring a live backend.
- Error handling: separates technical failures from user-facing messages.
- Architecture: should remain understandable and appropriate to project size.
20. Conclusion
Separating the UI, business logic, and data layers is a practical way to build maintainable iOS applications. Each layer has a clear purpose: the UI presents information, the business logic applies application rules, and the data layer manages information sources.
Using SwiftUI or UIKit with view models, repository interfaces, asynchronous networking, and dependency injection helps create code that is easier to test and change. Start with clear responsibilities and introduce additional abstractions only when they solve a real problem.
To explore structured iOS app development training, visit JustAcademy's iOS Training Course, register for a course demo, or visit the JustAcademy website.